From ce9e96bad42c7ef3886cf3c6c15e0ab593fdf480 Mon Sep 17 00:00:00 2001 From: OpenCode Agent Date: Thu, 15 Jan 2026 22:17:30 +0000 Subject: [PATCH 1/2] feat: hot-reload agent-authored skills and subagents --- package.json | 2 +- packages/app/src/context/global-sync.tsx | 66 ++++++++-- packages/opencode/src/agent/agent.ts | 45 ++++++- packages/opencode/src/bus/index.ts | 33 +++-- .../src/cli/cmd/tui/context/local.tsx | 9 +- .../opencode/src/cli/cmd/tui/context/sync.tsx | 40 +++++- packages/opencode/src/cli/cmd/tui/thread.ts | 1 + packages/opencode/src/command/index.ts | 47 ++++++- packages/opencode/src/config/config.ts | 70 +++++++++-- packages/opencode/src/file/watcher.ts | 116 +++++++++++++----- packages/opencode/src/flag/flag.ts | 2 + packages/opencode/src/plugin/index.ts | 3 +- packages/opencode/src/project/bootstrap.ts | 10 ++ packages/opencode/src/project/instance.ts | 12 ++ packages/opencode/src/project/state.ts | 10 ++ packages/opencode/src/server/server.ts | 3 +- packages/opencode/src/skill/skill.ts | 67 ++++++++-- packages/opencode/src/tool/registry.ts | 48 +++++++- packages/opencode/src/tool/skill.ts | 2 + packages/opencode/test/fixture/fixture.ts | 2 + packages/sdk/js/src/v2/gen/types.gen.ts | 22 ++++ 21 files changed, 518 insertions(+), 92 deletions(-) diff --git a/package.json b/package.json index f1d6c4fead1..8db569de1e3 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.5", + "packageManager": "bun@1.3.6", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "typecheck": "bun turbo typecheck", diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 94c39d2f0cb..441ded75e6e 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -19,6 +19,7 @@ import { type QuestionRequest, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" +import type { Event } from "@opencode-ai/sdk/v2" import { createStore, produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" @@ -144,6 +145,15 @@ function createGlobalSync() { return childStore } + function createClient(directory: string) { + return createOpencodeClient({ + baseUrl: globalSDK.url, + fetch: platform.fetch, + directory, + throwOnError: true, + }) + } + async function loadSessions(directory: string) { const [store, setStore] = child(directory) const limit = store.limit @@ -186,12 +196,7 @@ function createGlobalSync() { const [store, setStore] = child(directory) const cache = vcsCache.get(directory) if (!cache) return - const sdk = createOpencodeClient({ - baseUrl: globalSDK.url, - fetch: platform.fetch, - directory, - throwOnError: true, - }) + const sdk = createClient(directory) createEffect(() => { if (!cache.ready()) return @@ -307,7 +312,7 @@ function createGlobalSync() { const unsub = globalSDK.event.listen((e) => { const directory = e.name - const event = e.details + const event = e.details as Event if (directory === "global") { switch (event?.type) { @@ -339,6 +344,27 @@ function createGlobalSync() { bootstrapInstance(directory) break } + case "file.watcher.updated": { + const filepath = event.properties.file.replaceAll("\\", "/") + const segments = filepath.split("/").filter(Boolean) + const hasAgent = segments.includes("agent") || segments.includes("agents") + const hasCommand = segments.includes("command") || segments.includes("commands") + const hasSkill = segments.includes("skill") || segments.includes("skills") + const inConfig = filepath.includes("/.opencode/") || filepath.startsWith(".opencode/") + if (!inConfig) break + if (!hasAgent && !hasCommand && !hasSkill) break + + const sdk = createClient(directory) + const refresh = [] as Promise[] + if (hasAgent || hasSkill) { + refresh.push(sdk.app.agents().then((x) => setStore("agent", x.data ?? []))) + } + if (hasCommand) { + refresh.push(sdk.command.list().then((x) => setStore("command", x.data ?? []))) + } + Promise.all(refresh) + break + } case "session.updated": { const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) if (event.properties.info.time.archived) { @@ -528,15 +554,29 @@ function createGlobalSync() { break } case "lsp.updated": { - const sdk = createOpencodeClient({ - baseUrl: globalSDK.url, - fetch: platform.fetch, - directory, - throwOnError: true, - }) + const sdk = createClient(directory) sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])) break } + case "config.updated": { + const sdk = createClient(directory) + Promise.all([ + sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), + sdk.command.list().then((x) => setStore("command", x.data ?? [])), + sdk.config.get().then((x) => setStore("config", x.data!)), + ]) + break + } + case "command.updated": { + const sdk = createClient(directory) + sdk.command.list().then((x) => setStore("command", x.data ?? [])) + break + } + case "skill.updated": { + const sdk = createClient(directory) + sdk.app.agents().then((x) => setStore("agent", x.data ?? [])) + break + } } }) onCleanup(unsub) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 64875091916..469316eb373 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -5,6 +5,10 @@ import { generateObject, type ModelMessage } from "ai" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" import { Truncate } from "../tool/truncation" +import { Bus } from "@/bus" +import { Flag } from "@/flag/flag" +import { FileWatcher } from "@/file/watcher" +import { Filesystem } from "@/util/filesystem" import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" @@ -43,7 +47,7 @@ export namespace Agent { }) export type Info = z.infer - const state = Instance.state(async () => { + async function initState() { const cfg = await Config.get() const defaults = PermissionNext.fromConfig({ @@ -239,7 +243,9 @@ export namespace Agent { } return result - }) + } + + const state = Instance.state(initState) export async function get(agent: string) { return state().then((x) => x[agent]) @@ -250,7 +256,7 @@ export namespace Agent { return pipe( await state(), values(), - sortBy([(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"]), + sortBy([(item) => (cfg.default_agent ? item.name === cfg.default_agent : item.name === "build"), "desc"]), ) } @@ -258,6 +264,39 @@ export namespace Agent { return state().then((x) => Object.keys(x)[0]) } + export function initWatcher() { + if (!Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD()) return + + Bus.subscribe(FileWatcher.Event.Updated, async (event) => { + const filepath = event.properties.file.replaceAll("\\", "/") + const isUnlink = event.properties.event === "unlink" + + const configRoot = Global.Path.config.replaceAll("\\", "/") + const configDirs = await Config.directories() + const normalizedDirs = configDirs.map((dir) => dir.replaceAll("\\", "/")) + const looksLikeConfigDir = + filepath.includes("/.opencode/") || + filepath.startsWith(".opencode/") || + filepath.includes("/.config/opencode/") || + filepath.startsWith(".config/opencode/") + const inConfigDir = + looksLikeConfigDir || + filepath === configRoot || + filepath.startsWith(configRoot + "/") || + normalizedDirs.some((dir) => Filesystem.contains(dir, filepath) || filepath === dir) + + const segments = filepath.split("/").filter(Boolean) + const hasAgentSegment = segments.includes("agent") || segments.includes("agents") + const inAgentArea = inConfigDir && hasAgentSegment + const isAgentFile = inAgentArea && filepath.endsWith(".md") + const isAgentDir = isUnlink && inAgentArea + + if (!isAgentFile && !isAgentDir) return + + await Instance.invalidate(initState) + }) + } + export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) { const cfg = await Config.get() const defaultModel = input.model ?? (await Provider.defaultModel()) diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index edb093f1974..29cb4744b02 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -6,7 +6,7 @@ import { GlobalBus } from "./global" export namespace Bus { const log = Log.create({ service: "bus" }) - type Subscription = (event: any) => void + type Subscription = (event: unknown) => void | Promise export const InstanceDisposed = BusEvent.define( "server.instance.disposed", @@ -17,7 +17,7 @@ export namespace Bus { const state = Instance.state( () => { - const subscriptions = new Map() + const subscriptions = new Map() return { subscriptions, @@ -49,18 +49,24 @@ export namespace Bus { log.info("publishing", { type: def.type, }) - const pending = [] + const pending: Promise[] = [] for (const key of [def.type, "*"]) { const match = state().subscriptions.get(key) for (const sub of match ?? []) { - pending.push(sub(payload)) + const result = sub(payload) + pending.push(Promise.resolve(result)) + } + } + const results = await Promise.allSettled(pending) + for (const result of results) { + if (result.status === "rejected") { + log.error("subscriber failed", { error: result.reason }) } } GlobalBus.emit("event", { directory: Instance.directory, payload, }) - return Promise.all(pending) } export function subscribe( @@ -82,24 +88,25 @@ export namespace Bus { }) } - export function subscribeAll(callback: (event: any) => void) { + export function subscribeAll(callback: (event: Event) => void) { return raw("*", callback) } - function raw(type: string, callback: (event: any) => void) { + function raw(type: string, callback: (event: Event) => void) { log.info("subscribing", { type }) const subscriptions = state().subscriptions - let match = subscriptions.get(type) ?? [] - match.push(callback) + const match = subscriptions.get(type) ?? [] + const wrapped: Subscription = (event) => callback(event as Event) + match.push(wrapped) subscriptions.set(type, match) return () => { log.info("unsubscribing", { type }) - const match = subscriptions.get(type) - if (!match) return - const index = match.indexOf(callback) + const current = subscriptions.get(type) + if (!current) return + const index = current.indexOf(wrapped) if (index === -1) return - match.splice(index, 1) + current.splice(index, 1) } } } diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 63f1d9743bf..aa68db49404 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -54,7 +54,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return agents() }, current() { - return agents().find((x) => x.name === agentStore.current)! + const match = agents().find((x) => x.name === agentStore.current) + if (match) return match + const fallback = agents()[0] + if (!fallback) { + throw new Error("No agents available") + } + setAgentStore("current", fallback.name) + return fallback }, set(name: string) { if (!agents().some((x) => x.name === name)) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 0edc911344c..0ab5868bd06 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -17,6 +17,7 @@ import type { ProviderListResponse, ProviderAuthMethod, VcsInfo, + Event, } from "@opencode-ai/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" import { useSDK } from "@tui/context/sdk" @@ -105,11 +106,31 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sdk = useSDK() sdk.event.listen((e) => { - const event = e.details + const event = e.details as Event switch (event.type) { case "server.instance.disposed": bootstrap() break + case "file.watcher.updated": { + const filepath = event.properties.file.replaceAll("\\", "/") + const segments = filepath.split("/").filter(Boolean) + const hasAgent = segments.includes("agent") || segments.includes("agents") + const hasCommand = segments.includes("command") || segments.includes("commands") + const hasSkill = segments.includes("skill") || segments.includes("skills") + const inConfig = filepath.includes("/.opencode/") || filepath.startsWith(".opencode/") + if (!inConfig) break + if (!hasAgent && !hasCommand && !hasSkill) break + + const refresh = [] as Promise[] + if (hasAgent || hasSkill) { + refresh.push(sdk.client.app.agents().then((x) => setStore("agent", reconcile(x.data ?? [])))) + } + if (hasCommand) { + refresh.push(sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? [])))) + } + Promise.all(refresh) + break + } case "permission.replied": { const requests = store.permission[event.properties.sessionID] if (!requests) break @@ -304,6 +325,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore("vcs", { branch: event.properties.branch }) break } + + case "config.updated": { + setStore("config", reconcile(event.properties)) + sdk.client.app.agents().then((x) => setStore("agent", reconcile(x.data ?? []))) + sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))) + break + } + + case "command.updated": { + sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))) + break + } + + case "skill.updated": { + sdk.client.app.agents().then((x) => setStore("agent", reconcile(x.data ?? []))) + break + } } }) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 05714268545..8aede78c8df 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -144,6 +144,7 @@ export const TuiThreadCommand = cmd({ url, fetch: customFetch, events, + directory: cwd, args: { continue: args.continue, sessionID: args.session, diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 976f1cd51e9..01fc6f3c7eb 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -1,14 +1,20 @@ import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" import z from "zod" import { Config } from "../config/config" +import { FileWatcher } from "@/file/watcher" +import { Filesystem } from "@/util/filesystem" +import { Global } from "@/global" import { Instance } from "../project/instance" import { Identifier } from "../id/id" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" import { MCP } from "../mcp" +import { Flag } from "@/flag/flag" export namespace Command { export const Event = { + Updated: BusEvent.define("command.updated", z.object({})), Executed: BusEvent.define( "command.executed", z.object({ @@ -55,7 +61,7 @@ export namespace Command { REVIEW: "review", } as const - const state = Instance.state(async () => { + const initState = async () => { const cfg = await Config.get() const result: Record = { @@ -119,7 +125,9 @@ export namespace Command { } return result - }) + } + + const state = Instance.state(initState) export async function get(name: string) { return state().then((x) => x[name]) @@ -128,4 +136,39 @@ export namespace Command { export async function list() { return state().then((x) => Object.values(x)) } + + export function initWatcher() { + if (!Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD()) return + + Bus.subscribe(FileWatcher.Event.Updated, async (event) => { + const filepath = event.properties.file.replaceAll("\\", "/") + const isUnlink = event.properties.event === "unlink" + + const configRoot = Global.Path.config.replaceAll("\\", "/") + const configDirs = await Config.directories() + const normalizedDirs = configDirs.map((dir) => dir.replaceAll("\\", "/")) + const looksLikeConfigDir = + filepath.includes("/.opencode/") || + filepath.startsWith(".opencode/") || + filepath.includes("/.config/opencode/") || + filepath.startsWith(".config/opencode/") + const inConfigDir = + looksLikeConfigDir || + filepath === configRoot || + filepath.startsWith(configRoot + "/") || + normalizedDirs.some((dir) => Filesystem.contains(dir, filepath) || filepath === dir) + + const segments = filepath.split("/").filter(Boolean) + const hasCommandSegment = segments.includes("command") || segments.includes("commands") + const inCommandArea = inConfigDir && hasCommandSegment + const isCommandFile = inCommandArea && filepath.endsWith(".md") + const isCommandDir = isUnlink && inCommandArea + + if (!isCommandFile && !isCommandDir) return + + await Instance.invalidate(initState) + await list() + Bus.publish(Event.Updated, {}) + }) + } } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 322ce273ab8..3181aa9301a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -19,12 +19,20 @@ import { BunProc } from "@/bun" import { Installation } from "@/installation" import { ConfigMarkdown } from "./markdown" import { existsSync } from "fs" +import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" -import { Session } from "@/session" +import { FileWatcher } from "@/file/watcher" export namespace Config { const log = Log.create({ service: "config" }) + export const Events = { + Updated: BusEvent.define( + "config.updated", + z.lazy(() => Info), + ), + } + // Custom merge function that concatenates array fields instead of replacing them function mergeConfigConcatArrays(target: Info, source: Info): Info { const merged = mergeDeep(target, source) @@ -37,7 +45,7 @@ export namespace Config { return merged } - export const state = Instance.state(async () => { + async function initState(): Promise<{ config: Info; directories: string[] }> { const auth = await Auth.all() // Load remote/well-known config first as the base layer (lowest precedence) @@ -51,10 +59,12 @@ export namespace Config { if (!response.ok) { throw new Error(`failed to fetch remote config from ${key}: ${response.status}`) } - const wellknown = (await response.json()) as any - const remoteConfig = wellknown.config ?? {} + const wellknown = (await response.json()) as { config?: unknown } + const remoteConfig = typeof wellknown.config === "object" && wellknown.config !== null ? wellknown.config : {} // Add $schema to prevent load() from trying to write back to a non-existent file - if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" + if (typeof (remoteConfig as { $schema?: string }).$schema !== "string") { + ;(remoteConfig as { $schema?: string }).$schema = "https://opencode.ai/config.json" + } result = mergeConfigConcatArrays( result, await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`), @@ -186,7 +196,9 @@ export namespace Config { config: result, directories, } - }) + } + + export const state = Instance.state(initState) export async function installDependencies(dir: string) { const pkg = path.join(dir, "package.json") @@ -237,11 +249,10 @@ export namespace Config { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse command ${item}` - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load command", { command: item, err }) return undefined }) - if (!md) continue + if (!md?.data) continue const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"] const file = rel(item, patterns) ?? path.basename(item) @@ -276,11 +287,10 @@ export namespace Config { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse agent ${item}` - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load agent", { agent: item, err }) return undefined }) - if (!md) continue + if (!md?.data) continue const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"] const file = rel(item, patterns) ?? path.basename(item) @@ -314,11 +324,10 @@ export namespace Config { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse mode ${item}` - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load mode", { mode: item, err }) return undefined }) - if (!md) continue + if (!md?.data) continue const config = { name: path.basename(item, ".md"), @@ -1241,4 +1250,41 @@ export namespace Config { export async function directories() { return state().then((x) => x.directories) } + + export function initWatcher() { + if (!Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD()) return + + Bus.subscribe(FileWatcher.Event.Updated, async (event) => { + const filepath = event.properties.file.replaceAll("\\", "/") + const isUnlink = event.properties.event === "unlink" + + const configRoot = Global.Path.config.replaceAll("\\", "/") + const configDirs = await directories() + const normalizedDirs = configDirs.map((dir) => dir.replaceAll("\\", "/")) + const looksLikeConfigDir = + filepath.includes("/.opencode/") || + filepath.startsWith(".opencode/") || + filepath.includes("/.config/opencode/") || + filepath.startsWith(".config/opencode/") + const inConfigDir = + looksLikeConfigDir || + filepath === configRoot || + filepath.startsWith(configRoot + "/") || + normalizedDirs.some((dir) => Filesystem.contains(dir, filepath) || filepath === dir) + + const segments = filepath.split("/").filter(Boolean) + const hasAgent = segments.includes("agent") || segments.includes("agents") + const hasCommand = segments.includes("command") || segments.includes("commands") + const hasSkill = segments.includes("skill") || segments.includes("skills") + const inArea = inConfigDir && (hasAgent || hasCommand || hasSkill) + const isFile = inArea && filepath.endsWith(".md") + const isDir = isUnlink && inArea + + if (!isFile && !isDir) return + + await Instance.invalidate(initState) + const cfg = await get() + Bus.publish(Events.Updated, cfg) + }) + } } diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 44f8a0a3a4a..121efb07324 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -5,6 +5,7 @@ import { Instance } from "../project/instance" import { Log } from "../util/log" import { FileIgnore } from "./ignore" import { Config } from "../config/config" +import { Global } from "@/global" import path from "path" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" @@ -33,15 +34,15 @@ export namespace FileWatcher { } const watcher = lazy(() => { - const binding = require( - `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`, - ) + const libc = typeof OPENCODE_LIBC === "string" && OPENCODE_LIBC.length > 0 ? OPENCODE_LIBC : "glibc" + const suffix = process.platform === "linux" ? `-${libc}` : "" + const binding = require(`@parcel/watcher-${process.platform}-${process.arch}${suffix}`) + return createWrapper(binding) as typeof import("@parcel/watcher") }) const state = Instance.state( async () => { - if (Instance.project.vcs !== "git") return {} log.info("init") const cfg = await Config.get() const backend = (() => { @@ -54,51 +55,102 @@ export namespace FileWatcher { return {} } log.info("watcher backend", { platform: process.platform, backend }) + + const directory = Instance.directory const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => { if (err) return - for (const evt of evts) { - if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) - if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) - if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) - } + Instance.runInContext(directory, () => { + for (const evt of evts) { + if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) + if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) + if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) + } + }) } const subs: ParcelWatcher.AsyncSubscription[] = [] const cfgIgnores = cfg.watcher?.ignore ?? [] + const watchedPaths = new Set() + + const isInsideWatchedPath = (targetPath: string) => { + const normalizedTarget = path.resolve(targetPath) + for (const watched of watchedPaths) { + const normalizedWatched = path.resolve(watched) + if (normalizedTarget === normalizedWatched || normalizedTarget.startsWith(normalizedWatched + path.sep)) { + return true + } + } + return false + } - if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { + if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER || Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD()) { const pending = watcher().subscribe(Instance.directory, subscribe, { ignore: [...FileIgnore.PATTERNS, ...cfgIgnores], backend, }) const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { - log.error("failed to subscribe to Instance.directory", { error: err }) + log.error("failed to subscribe", { path: Instance.directory, error: err }) pending.then((s) => s.unsubscribe()).catch(() => {}) return undefined }) - if (sub) subs.push(sub) + if (sub) { + subs.push(sub) + watchedPaths.add(Instance.directory) + log.info("watching", { path: Instance.directory }) + } } - const vcsDir = await $`git rev-parse --git-dir` - .quiet() - .nothrow() - .cwd(Instance.worktree) - .text() - .then((x) => path.resolve(Instance.worktree, x.trim())) - .catch(() => undefined) - if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { - const gitDirContents = await readdir(vcsDir).catch(() => []) - const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD") - const pending = watcher().subscribe(vcsDir, subscribe, { - ignore: ignoreList, - backend, - }) - const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { - log.error("failed to subscribe to vcsDir", { error: err }) - pending.then((s) => s.unsubscribe()).catch(() => {}) - return undefined - }) - if (sub) subs.push(sub) + const isGit = Instance.project.vcs === "git" + if (isGit) { + const vcsDir = await $`git rev-parse --git-dir` + .quiet() + .nothrow() + .cwd(Instance.worktree) + .text() + .then((x) => path.resolve(Instance.worktree, x.trim())) + if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { + const gitDirContents = await readdir(vcsDir).catch(() => []) + const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD") + const pending = watcher().subscribe(vcsDir, subscribe, { + ignore: ignoreList, + backend, + }) + const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { + log.error("failed to subscribe", { path: vcsDir, error: err }) + pending.then((s) => s.unsubscribe()).catch(() => {}) + return undefined + }) + if (sub) { + subs.push(sub) + watchedPaths.add(vcsDir) + log.info("watching", { path: vcsDir }) + } + } + } + + if (Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD()) { + const configDirectories = await Config.directories() + for (const dir of configDirectories) { + if (isInsideWatchedPath(dir)) { + log.debug("skipping duplicate watch", { path: dir, reason: "already inside watched path" }) + continue + } + + const pending = watcher().subscribe(dir, subscribe, { + ignore: [...FileIgnore.PATTERNS], + backend, + }) + const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { + log.error("failed to subscribe to config dir", { path: dir, error: err }) + pending.then((s) => s.unsubscribe()).catch(() => {}) + return undefined + }) + if (sub) { + subs.push(sub) + watchedPaths.add(dir) + log.info("watching", { path: dir }) + } + } } return { subs } diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 4cdb549096a..38566bb0797 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -40,6 +40,8 @@ export namespace Flag { export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL") export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE") + export const OPENCODE_EXPERIMENTAL_HOT_RELOAD = () => truthy("OPENCODE_EXPERIMENTAL_HOT_RELOAD") + function truthy(key: string) { const value = process.env[key]?.toLowerCase() return value === "true" || value === "1" diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 84de520b81d..85497b5ebe1 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -3,6 +3,7 @@ import { Config } from "../config/config" import { Bus } from "../bus" import { Log } from "../util/log" import { createOpencodeClient } from "@opencode-ai/sdk" +import type { Event } from "@opencode-ai/sdk" import { Server } from "../server/server" import { BunProc } from "../bun" import { Instance } from "../project/instance" @@ -123,7 +124,7 @@ export namespace Plugin { // @ts-expect-error this is because we haven't moved plugin to sdk v2 await hook.config?.(config) } - Bus.subscribeAll(async (input) => { + Bus.subscribeAll(async (input) => { const hooks = await state().then((x) => x.hooks) for (const hook of hooks) { hook["event"]?.({ diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 56fe4d13e66..e35da89c643 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -11,6 +11,10 @@ import { Instance } from "./instance" import { Vcs } from "./vcs" import { Log } from "@/util/log" import { ShareNext } from "@/share/share-next" +import { Config } from "../config/config" +import { Skill } from "../skill/skill" +import { Agent } from "@/agent/agent" +import { Flag } from "@/flag/flag" export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) @@ -20,6 +24,12 @@ export async function InstanceBootstrap() { Format.init() await LSP.init() FileWatcher.init() + if (Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD()) { + Config.initWatcher() + Command.initWatcher() + Agent.initWatcher() + Skill.initWatcher() + } File.init() Vcs.init() diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index ddaa90f1e2b..810a1216609 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -13,6 +13,7 @@ interface Context { } const context = Context.create("instance") const cache = new Map>() +const resolved = new Map() export const Instance = { async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { @@ -29,6 +30,7 @@ export const Instance = { await context.provide(ctx, async () => { await input.init?.() }) + resolved.set(input.directory, ctx) return ctx }) cache.set(input.directory, existing) @@ -62,10 +64,14 @@ export const Instance = { state(init: () => S, dispose?: (state: Awaited) => Promise): () => S { return State.create(() => Instance.directory, init, dispose) }, + async invalidate(init: () => S) { + return State.invalidate(() => Instance.directory, init) + }, async dispose() { Log.Default.info("disposing instance", { directory: Instance.directory }) await State.dispose(Instance.directory) cache.delete(Instance.directory) + resolved.delete(Instance.directory) GlobalBus.emit("event", { directory: Instance.directory, payload: { @@ -87,5 +93,11 @@ export const Instance = { } } cache.clear() + resolved.clear() + }, + runInContext(directory: string, fn: () => R): R | undefined { + const ctx = resolved.get(directory) + if (!ctx) return undefined + return context.provide(ctx, fn) }, } diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index 34a5dbb3e71..5c52f9ebf39 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -28,6 +28,16 @@ export namespace State { } } + export async function invalidate(root: () => string, init: () => S) { + const key = root() + const entries = recordsByKey.get(key) + if (!entries) return + const entry = entries.get(init) + if (!entry) return + if (entry.dispose) await entry.dispose(await entry.state) + entries.delete(init) + } + export async function dispose(key: string) { const entries = recordsByKey.get(key) if (!entries) return diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7015c818822..c8d4a4e29bb 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -9,6 +9,7 @@ import { stream, streamSSE } from "hono/streaming" import { proxy } from "hono/proxy" import { basicAuth } from "hono/basic-auth" import { Session } from "../session" +import type { Event } from "@opencode-ai/sdk/v2" import z from "zod" import { Provider } from "../provider/provider" import { filter, mapValues, sortBy, pipe } from "remeda" @@ -2802,7 +2803,7 @@ export namespace Server { properties: {}, }), }) - const unsub = Bus.subscribeAll(async (event) => { + const unsub = Bus.subscribeAll(async (event) => { await stream.writeSSE({ data: JSON.stringify(event), }) diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 6ae0e9fe887..033f9683069 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -1,19 +1,30 @@ import z from "zod" -import path from "path" import { Config } from "../config/config" import { Instance } from "../project/instance" import { NamedError } from "@opencode-ai/util/error" import { ConfigMarkdown } from "../config/markdown" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { FileWatcher } from "@/file/watcher" +import path from "path" import { Log } from "../util/log" import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" import { Flag } from "@/flag/flag" -import { Bus } from "@/bus" -import { TuiEvent } from "@/cli/cmd/tui/event" -import { Session } from "@/session" export namespace Skill { const log = Log.create({ service: "skill" }) + + export const Events = { + Updated: BusEvent.define( + "skill.updated", + z.record( + z.string(), + z.lazy(() => Info), + ), + ), + } + export const Info = z.object({ name: z.string(), description: z.string(), @@ -42,7 +53,7 @@ export namespace Skill { const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md") const CLAUDE_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md") - export const state = Instance.state(async () => { + async function initState(): Promise> { const skills: Record = {} const addSkill = async (match: string) => { @@ -50,12 +61,12 @@ export namespace Skill { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse skill ${match}` - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load skill", { skill: match, err }) return undefined }) - - if (!md) return + if (!md?.data) { + return + } const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) if (!parsed.success) return @@ -124,7 +135,9 @@ export namespace Skill { } return skills - }) + } + + export const state = Instance.state(initState) export async function get(name: string) { return state().then((x) => x[name]) @@ -133,4 +146,40 @@ export namespace Skill { export async function all() { return state().then((x) => Object.values(x)) } + + export function initWatcher() { + if (!Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD()) return + + Bus.subscribe(FileWatcher.Event.Updated, async (event) => { + const filepath = event.properties.file.replaceAll("\\", "/") + const isSkillFile = filepath.endsWith("SKILL.md") + const isUnlink = event.properties.event === "unlink" + + const configRoot = Global.Path.config.replaceAll("\\", "/") + const configDirs = await Config.directories() + const normalizedDirs = configDirs.map((dir) => dir.replaceAll("\\", "/")) + const looksLikeConfigDir = + filepath.includes("/.opencode/") || + filepath.startsWith(".opencode/") || + filepath.includes("/.config/opencode/") || + filepath.startsWith(".config/opencode/") + const looksLikeClaudeDir = filepath.includes("/.claude/") || filepath.startsWith(".claude/") + const inConfigDir = + looksLikeConfigDir || + filepath === configRoot || + filepath.startsWith(configRoot + "/") || + normalizedDirs.some((dir) => Filesystem.contains(dir, filepath) || filepath === dir) + + const segments = filepath.split("/").filter(Boolean) + const hasSkillSegment = segments.includes("skill") || segments.includes("skills") + const inSkillArea = hasSkillSegment && (inConfigDir || looksLikeClaudeDir) + const isSkillDir = isUnlink && inSkillArea + + if (!isSkillFile && !isSkillDir) return + + await Instance.invalidate(initState) + const skills = await state() + Bus.publish(Events.Updated, skills) + }) + } } diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 35e378f080b..1556848f5ee 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -11,10 +11,15 @@ import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" import { SkillTool } from "./skill" +import { Skill } from "../skill/skill" import type { Agent } from "../agent/agent" import { Tool } from "./tool" import { Instance } from "../project/instance" import { Config } from "../config/config" +import { Bus } from "@/bus" +import { FileWatcher } from "@/file/watcher" +import { Filesystem } from "@/util/filesystem" +import { Global } from "@/global" import path from "path" import { type ToolDefinition } from "@opencode-ai/plugin" import z from "zod" @@ -30,10 +35,12 @@ import { PlanExitTool, PlanEnterTool } from "./plan" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) - export const state = Instance.state(async () => { + const initState = async () => { const custom = [] as Tool.Info[] const glob = new Bun.Glob("{tool,tools}/*.{js,ts}") + const cacheBust = Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD() ? `?t=${Date.now()}` : "" + for (const dir of await Config.directories()) { for await (const match of glob.scan({ cwd: dir, @@ -42,7 +49,7 @@ export namespace ToolRegistry { dot: true, })) { const namespace = path.basename(match, path.extname(match)) - const mod = await import(match) + const mod = await import(match + cacheBust) for (const [id, def] of Object.entries(mod)) { custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) } @@ -57,7 +64,9 @@ export namespace ToolRegistry { } return { custom } - }) + } + + export const state = Instance.state(initState) function fromPlugin(id: string, def: ToolDefinition): Tool.Info { return { @@ -115,6 +124,39 @@ export namespace ToolRegistry { ] } + export function initWatcher() { + if (!Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD()) return + + Bus.subscribe(FileWatcher.Event.Updated, async (event) => { + const filepath = event.properties.file.replaceAll("\\", "/") + const isUnlink = event.properties.event === "unlink" + + const configRoot = Global.Path.config.replaceAll("\\", "/") + const configDirs = await Config.directories() + const normalizedDirs = configDirs.map((dir) => dir.replaceAll("\\", "/")) + const looksLikeConfigDir = + filepath.includes("/.opencode/") || + filepath.startsWith(".opencode/") || + filepath.includes("/.config/opencode/") || + filepath.startsWith(".config/opencode/") + const inConfigDir = + looksLikeConfigDir || + filepath === configRoot || + filepath.startsWith(configRoot + "/") || + normalizedDirs.some((dir) => Filesystem.contains(dir, filepath) || filepath === dir) + + const segments = filepath.split("/").filter(Boolean) + const hasToolSegment = segments.includes("tool") || segments.includes("tools") + const inToolArea = inConfigDir && hasToolSegment + const isToolFile = inToolArea && (filepath.endsWith(".ts") || filepath.endsWith(".js")) + const isToolDir = isUnlink && inToolArea + + if (!isToolFile && !isToolDir) return + + await Instance.invalidate(initState) + }) + } + export async function ids() { return all().then((x) => x.map((t) => t.id)) } diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 386abdae745..443f443da2a 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -57,6 +57,8 @@ export const SkillTool = Tool.define("skill", async (ctx) => { }) // Load and parse skill content const parsed = await ConfigMarkdown.parse(skill.location) + if (!parsed) throw new Error(`Failed to parse skill "${params.name}"`) + const dir = path.dirname(skill.location) // Format output similar to plugin pattern diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index ed8c5e344a8..bc49747b41b 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -20,6 +20,8 @@ export async function tmpdir(options?: TmpDirOptions) { await fs.mkdir(dirpath, { recursive: true }) if (options?.git) { await $`git init`.cwd(dirpath).quiet() + await $`git config user.email "you@example.com"`.cwd(dirpath).quiet() + await $`git config user.name "Your Name"`.cwd(dirpath).quiet() await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet() } if (options?.config) { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9c4f0e50d12..5214976f6f6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -845,6 +845,25 @@ export type EventGlobalDisposed = { } } +export type EventConfigUpdated = { + type: "config.updated" + properties: Config +} + +export type EventCommandUpdated = { + type: "command.updated" + properties: { + [key: string]: unknown + } +} + +export type EventSkillUpdated = { + type: "skill.updated" + properties: { + [key: string]: unknown + } +} + export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable @@ -885,6 +904,9 @@ export type Event = | EventPtyDeleted | EventServerConnected | EventGlobalDisposed + | EventConfigUpdated + | EventCommandUpdated + | EventSkillUpdated export type GlobalEvent = { directory: string From d1fadc4421a1f5b12bcff6f6e5d5a1bf6f638c49 Mon Sep 17 00:00:00 2001 From: Github Action Date: Thu, 15 Jan 2026 22:20:04 +0000 Subject: [PATCH 2/2] Update node_modules hash (aarch64-linux) --- nix/hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/hashes.json b/nix/hashes.json index dda3b6fc3f0..f05526d035e 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,7 +1,7 @@ { "nodeModules": { "x86_64-linux": "sha256-Fl1BdjNSg19LJVSgDMiBX8JuTaGlL2I5T+rqLfjSeO4=", - "aarch64-linux": "sha256-H9eUk/yVrQqVrAYONlb6As7mjkPXtOauBVfMBeVAmRo=", + "aarch64-linux": "sha256-6d20RnBuhOUMaY+5Ms/IOAta1HqHCtb/3yjkGsPgJzA=", "aarch64-darwin": "sha256-C0E9KAEj3GI83HwirIL2zlXYIe92T+7Iv6F51BB6slY=", "x86_64-darwin": "sha256-u3izLZJZ0+KVqOu0agm4lBY8A3cY62syF0QaL9c1E/g=" }