Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f5d17c3
feat: implement hot-reload for agents, skills, and config
Jan 15, 2026
616fdc0
chore: add planning and memory files for hot-reload feature
Jan 15, 2026
6de5f81
fix: handle undefined parse result in SkillTool
Jan 15, 2026
18e3065
fix: harden hot-reload cache handling
Jan 15, 2026
2c95b58
fix: restore typed bus events
Jan 15, 2026
a8d9145
chore: trim hot-reload noise
Jan 15, 2026
db79569
test: trim hot-reload setup
Jan 15, 2026
aa0b691
fix: restore hot-reload watchers
Jan 15, 2026
36bbc84
fix: refresh agents on config change
Jan 15, 2026
9daf571
fix: broaden config hot-reload triggers
Jan 15, 2026
cf290d7
fix: always watch global config dir
Jan 15, 2026
396092e
fix(opencode): remove config hot reload
Jan 15, 2026
6014eaa
fix(opencode): limit hot reload to agent files
Jan 15, 2026
abe47cc
fix(opencode): drop config hot reload flag
Jan 15, 2026
84e47d1
chore(sdk): regenerate api
Jan 15, 2026
0fb6b50
fix(opencode): restore agentic hot reload
Jan 15, 2026
9b52e26
fix(app): refresh agents on file watch
Jan 15, 2026
b006df3
fix(opencode): always enable agentic hot reload
Jan 15, 2026
3005b5a
Revert "fix(opencode): always enable agentic hot reload"
Jan 15, 2026
60fd5d7
fix(opencode): reload agentic config on file changes
Jan 15, 2026
9bbbe0f
fix(opencode): restore tool hot reload hook
Jan 15, 2026
2a94351
test(opencode): remove hot reload tests
Jan 15, 2026
00e407c
refactor(opencode): rename hot reload flag
Jan 15, 2026
e4cb7cc
chore(sdk): align generated types with dev
Jan 15, 2026
762599b
Revert "chore(sdk): align generated types with dev"
Jan 15, 2026
97ca26e
Revert "chore(sdk): regenerate api"
Jan 15, 2026
16a9d11
chore(sdk): align generated sdk with dev
Jan 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 48 additions & 13 deletions packages/app/src/context/global-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
Expand Down Expand Up @@ -117,6 +118,15 @@ function createGlobalSync() {
return children[directory]
}

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
Expand Down Expand Up @@ -157,12 +167,7 @@ function createGlobalSync() {
async function bootstrapInstance(directory: string) {
if (!directory) return
const [store, setStore] = child(directory)
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory,
throwOnError: true,
})
const sdk = createClient(directory)

const blockingRequests = {
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
Expand Down Expand Up @@ -267,7 +272,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) {
Expand Down Expand Up @@ -299,6 +304,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<unknown>[]
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) {
Expand Down Expand Up @@ -485,15 +511,24 @@ 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 "skill.updated": {
const sdk = createClient(directory)
sdk.app.agents().then((x) => setStore("agent", x.data ?? []))
break
}
}
})
onCleanup(unsub)
Expand Down
45 changes: 42 additions & 3 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -43,7 +47,7 @@ export namespace Agent {
})
export type Info = z.infer<typeof Info>

const state = Instance.state(async () => {
async function initState() {
const cfg = await Config.get()

const defaults = PermissionNext.fromConfig({
Expand Down Expand Up @@ -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])
Expand All @@ -250,14 +256,47 @@ 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"]),
)
}

export async function defaultAgent() {
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())
Expand Down
33 changes: 20 additions & 13 deletions packages/opencode/src/bus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>

export const InstanceDisposed = BusEvent.define(
"server.instance.disposed",
Expand All @@ -17,7 +17,7 @@ export namespace Bus {

const state = Instance.state(
() => {
const subscriptions = new Map<any, Subscription[]>()
const subscriptions = new Map<string, Subscription[]>()

return {
subscriptions,
Expand Down Expand Up @@ -49,18 +49,24 @@ export namespace Bus {
log.info("publishing", {
type: def.type,
})
const pending = []
const pending: Promise<unknown>[] = []
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<Definition extends BusEvent.Definition>(
Expand All @@ -82,24 +88,25 @@ export namespace Bus {
})
}

export function subscribeAll(callback: (event: any) => void) {
export function subscribeAll<Event = unknown>(callback: (event: Event) => void) {
return raw("*", callback)
}

function raw(type: string, callback: (event: any) => void) {
function raw<Event>(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)
}
}
}
9 changes: 8 additions & 1 deletion packages/opencode/src/cli/cmd/tui/context/local.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
35 changes: 34 additions & 1 deletion packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<unknown>[]
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
Expand Down Expand Up @@ -304,6 +325,18 @@ 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 "skill.updated": {
sdk.client.app.agents().then((x) => setStore("agent", reconcile(x.data ?? [])))
break
}
}
})

Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export const TuiThreadCommand = cmd({
url,
fetch: customFetch,
events,
directory: cwd,
args: {
continue: args.continue,
sessionID: args.session,
Expand Down
Loading
Loading